Breadcrumbs Livecomponent in Phoenix Liveview
Navigating a site should be as easy and simple as possible, this could be proven difficult on a website consisting of many pages. We’ll build a live component that will serve as a secondary navigation tool. It will hold the hierarchy of subpages leading to the current view and render them as links.
This article’s objective is to show how to construct a breadcrumbs navigation for Phoenix Liveview.
Contents
Preparing the necessities
Before we start doing anything we’ll need some liveview files. You can set them up yourself or clone the blog repo.
If you cloned the blog you can skip this section, otherwise start a new Phoenix project(name it my_blog
for the sake of continuity) and prepare some liveview files to work with. Create a new folder in lib/my_blog_web/
called live
, inside, three live files: home_live.ex
, blog_live.ex
and article_live.ex
. While building the pages provide a link to those pages so the final path to the article_live.ex
view will be http://localhost:4000/blog/article
.Remember to implement the mount/3
and render/1
:live_view
callbacks.
defmodule MyBlogWeb.HomeLive do
use MyBlogWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
~H"""
<section class="flex flex-col items-center gap-4">
<h1 class="py-8">
<%= __MODULE__ %>
</h1>
<.link class="p-2 bg-accent text-text-ui hover:ring-2 ring-accent hover:text-accent hover:bg-white active:text-accent/70 w-fit rounded-md font-semibold" navigate={"/blog"}>
Blog
</.link>
</section>
"""
end
end
Do this for the remaining modules (excluding the <.link />
element from article_live.ex
, as we won’t be pointing to any page from there). Returning a <h1>
heading with the name of the module and a link pointing to the next liveview will suffice for our use case. To render our liveviews in the browser we have to first set up the router. Open router.ex
in lib/my_blog_web/
directory, locate the default scope "/"
and replace the route generated by the app with paths seen bellow and our liveviews:
scope "/", MyBlogWeb do
pipe_through :browser
live "/", HomeLive
live "/blog", BlogLive
live "/blog/article", ArticleLive
end
Building the component
Loading view
Great, with this initial setup we can proceed with the component itself. In lib/my_blog_web/components/
create a new file breadcrumbs.ex
, define it and inject live_component/1 code. Take a look at the provided block.
defmodule MyBlogWeb.Breadcrumbs do
use MyBlogWeb, :live_component
def update(_assigns, socket) do
{:ok, socket |> assign(:uri, "")}
end
def render(%{:uri => ""} = assigns) do
~H"""
<div class="hidden sm:flex items-center animate-pulse ring-2 ring-accent p-1 px-6 mx-9 mt-10 rounded-md min-h-10">
<div class="rounded-md font-semibold font-mono flex items-center">
<.link
class="break-keep inline-block text-center text-sm p-1 rounded-md"
navigate={"/"}>
home
</.link>
</div>
</div>
"""
end
end
In the update/2
callback we’re assigning the :uri
as an empty string. Now, in render/1
we’re pattern matching the assings to the previously set value to display the initial state of markup. This will be our “loading” view, when we receive a new value from a liveview, we’ll use send_update/3 to run component’s update/2
once again. This time pattern matching to the assigns incoming with a new uri
. Then, we’ll run some functions to split and format the new :uri
. This, in turn, will trigger render/1
callback with different assigns, to which we’ll pattern match, and render links directing to the each part of path.
Logic
Ok, let’s take care of the send_update/3
. Create a new function update_component
, it will take component’s id and uri
as arguments and call send_update/3
. Right above already present update/2
callback, define a second update/2
(order is important, as our update(_assigns, socket)
takes its first argument as a variable and will always match) we’ll pattern match the assigns to a map that looks like this %{:update => uri}
. Afterwards, we’ll split the uri
by “/“, remove first three elements (these will represent the protocol, an empty string and the domain - we don’t want them on the screen), remove remaining empty strings and decode any escaped characters. If we’d run the function with the path to the article page we constructed earlier (http://localhost:4000/blog/article), we would see a list of strings ["blog", "article"]
. Thats fine, but what we actually need is a list of tuples with strings describing the path and path itself e.g.: {“blog”, “/blog”}. We can build those tuples quite easily using recursion. Define a new private function prep_uri
that will take a list of strings and an empty list initially. We’ll recursivelly loop over the string list and join
strings with a separator “/“, each step will remove the first element and accumulate the paths until the list is empty. Now all we have to do is to zip
elements from both lists together as tuples and assign them as :uri
. Our module will now look like this:
As a note, some styles here are custom, so you might want to replace them if you haven’t cloned the blog
repo.
defmodule MyBlogWeb.Breadcrumbs do
use MyBlogWeb, :live_component
def update_component(cid, assigns) do
send_update(__MODULE__, id: cid, update: assigns)
end
def update(%{:update => uri}, socket) do
sliced =
uri
|> String.split("/")
|> Enum.drop(3)
|> Enum.filter(fn x -> x != "" end)
|> Enum.map(fn x -> URI.decode(x) end)
uris = prep_uri(sliced, [])
{:ok, socket |> assign(:uri, Enum.zip(sliced, uris))}
end
def update(_assigns, socket) do
{:ok, socket |> assign(:uri, "")}
end
defp prep_uri(uris, acc) when is_list(uris) do
if length(uris) == 0 do
Enum.map(acc, fn a -> "/" <> a end)
else
uri_prepped = Enum.join(uris, "/")
new_uris =
uris |> Enum.reverse() |> tl |> Enum.reverse()
prep_uri(new_uris, [uri_prepped | acc])
end
end
def render(%{:uri => ""} = assigns) do
~H"""
<div class="hidden sm:flex items-center animate-pulse ring-2 ring-accent p-1 px-6 mx-9 mt-10 rounded-md min-h-10">
<nav aria="navigation" aria-label="breadcrumbs navigation" class="rounded-md font-semibold font-mono flex items-center">
<span class="p-1 inline-block">
<.link
class="break-keep inline-block text-center text-sm p-1 rounded-md hover:bg-accent hover:text-text-ui"
navigate={"/"}>
home
</.link>
</span>
</nav>
</div>
"""
end
end
That’s a lot of code, nonetheless we have to construct additional render/2
callback with markup that incorporates our paths.
def render(%{:uri => _uri} = assigns) do
~H"""
<div class="hidden sm:flex items-center ring-2 ring-accent p-1 px-6 mx-9 mt-10 rounded-md min-h-10">
<nav aria="navigation" aria-label="breadcrumbs navigation" class="rounded-md font-semibold font-mono flex items-center overflow-hidden truncate">
<span class="p-1 inline-block">
<.link
class="break-keep inline-block text-center text-sm p-1 rounded-md hover:bg-accent hover:text-text-ui"
navigate={"/"}>
home
</.link>
</span>
<%= for u <- @uri do %>
<%= if Enum.at(@uri, length(@uri)-1) == u do %>
<span class="p-1 inline-block">
<span aria-hidden="true">></span>
<span aria-label="this page" class="break-keep inline-block text-center text-sm p-1 text-accent rounded-md">
<%= elem(u, 0) %>
</span>
</span>
<% else %>
<span class="p-1 inline-block">
<span aria-hidden="true">></span>
<.link
class="break-keep inline-block text-center text-sm p-1 rounded-md hover:bg-accent hover:text-text-ui"
navigate={elem(u, 1)}
>
<%= elem(u, 0) %>
</.link>
</span>
<% end %>
<% end %>
</nav>
</div>
"""
end
Hooking it in
To see the component rendered we have to place it somewhere, layouts are great for this. We could position our component in app.html.heex
, but it won’t really make much sense to show breadcrumbs on the homepage. Let’s then create a new layout for our blog. In lib/my_blog_web/components/layouts/
create a new one, we’ll call it blog.html.heex
, it will be an exact copy of the app layout with an addition of the bredcrumbs
component. The live_component
takes two arguments: module
lets it know which component to render, id
on the other hand, helps it differentiate them in case of multiple components (originating from the same module) being rendered.
<.live_component module={MyBlogWeb.Header.Header} id="header" />
<.live_component module={MyBlogWeb.Breadcrumbs} id="breadcrumbs_navigation" />
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl">
<.flash_group flash={@flash} />
{@inner_content}
</div>
</main>
One aspect remains, that is sending current uri
to the component. In blog_live.ex
and article_live.ex
we have to implement handle_params/3
callback, it runs after mount/1
and receives the uri
. All we have to do is call our Breadcrumbs.update_component
:
def handle_params(_unsigned_params, uri, socket) do
MyBlogWeb.Breadcrumbs.update_component("breadcrumbs_navigation", uri)
{:noreply, socket}
end
When we now go to http://localhost:4000 we’ll see the result of our work:
That was fun. The only thing to keep in mind is that we need keep using MyBlogWeb.Breadcrumbs.update_component/2
in every liveview, so the component is updated on navigation, otherwise we’ll see only the “loading” state.